Skip to content

feat: add v2 codemod draft#1950

Draft
KKonstantinov wants to merge 20 commits intomainfrom
feature/v2-codemode-draft
Draft

feat: add v2 codemod draft#1950
KKonstantinov wants to merge 20 commits intomainfrom
feature/v2-codemode-draft

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

@KKonstantinov KKonstantinov commented Apr 23, 2026

Adds a new @modelcontextprotocol/codemod package that automatically migrates MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to the v2 multi-package architecture (@modelcontextprotocol/client, /server, /core, /node, /express). Powered by ts-morph for AST-level precision and shipped as both a CLI (mcp-codemod) and a programmatic API.

Motivation and Context

The v2 SDK introduces a multi-package structure, renamed APIs, restructured context objects, and removed modules (WebSocket transport, server auth, Zod helpers). Manually migrating a codebase is tedious, error-prone, and blocks adoption. This codemod handles the mechanical 80-90% of migration — rewriting imports, renaming symbols, updating method signatures, and mapping context properties — while emitting actionable diagnostics for the remaining cases that require human judgment.

Architecture

Package Structure

packages/codemod/
├── src/
│   ├── cli.ts                  # Commander CLI entry point
│   ├── runner.ts               # Core orchestrator
│   ├── types.ts                # Transform / Migration / Diagnostic types
│   ├── index.ts                # Public API exports
│   ├── migrations/
│   │   ├── index.ts            # Migration registry
│   │   └── v1-to-v2/
│   │       ├── index.ts        # Migration definition
│   │       ├── mappings/       # Declarative lookup tables
│   │       │   ├── importMap.ts
│   │       │   ├── symbolMap.ts
│   │       │   ├── schemaToMethodMap.ts
│   │       │   └── contextPropertyMap.ts
│   │       └── transforms/     # Ordered AST transforms
│   │           ├── index.ts
│   │           ├── importPaths.ts
│   │           ├── symbolRenames.ts
│   │           ├── removedApis.ts
│   │           ├── mcpServerApi.ts
│   │           ├── handlerRegistration.ts
│   │           ├── schemaParamRemoval.ts
│   │           ├── expressMiddleware.ts
│   │           ├── contextTypes.ts
│   │           └── mockPaths.ts
│   └── utils/
│       ├── astUtils.ts         # AST rename helpers
│       ├── diagnostics.ts      # Diagnostic factories
│       ├── importUtils.ts      # Import manipulation
│       └── projectAnalyzer.ts  # Detects client/server/both
└── test/                       # 12 test suites, 138 test cases

High-Level Flow

flowchart TD
    A["mcp-codemod v1-to-v2 ./src"] --> B[CLI parses args]
    B --> C[Runner loads migration]
    C --> D[Analyze project type<br/>from package.json]
    D --> E[Create ts-morph Project<br/>glob .ts/.tsx/.mts files]
    E --> F[Filter out node_modules,<br/>dist, .d.ts, .d.mts]
    F --> G{For each<br/>source file}
    G --> H[Apply transforms<br/>in order]
    H --> I[Collect diagnostics<br/>& change counts]
    I --> G
    G -->|done| J{Dry run?}
    J -->|yes| K[Report changes<br/>without saving]
    J -->|no| L[Save modified files<br/>to disk]
    K --> M[Print summary:<br/>files changed,<br/>diagnostics]
    L --> M
Loading

Transform Pipeline

Transforms run in a strict order per file. Each transform receives the SourceFile AST, mutates it in place, and returns a change count plus diagnostics. One failing transform does not block the others.

flowchart LR
    subgraph "Phase 1: Foundation"
        T1["1. importPaths<br/>Rewrite import specifiers<br/>from v1 → v2 packages"]
    end

    subgraph "Phase 2: Symbols"
        T2["2. symbolRenames<br/>McpError → ProtocolError<br/>ErrorCode split, etc."]
        T3["3. removedApis<br/>Drop Zod helpers,<br/>IsomorphicHeaders"]
    end

    subgraph "Phase 3: API Surface"
        T4["4. mcpServerApi<br/>.tool() → .registerTool()<br/>restructure args"]
        T5["5. handlerRegistration<br/>Schema → method string"]
        T6["6. schemaParamRemoval<br/>Remove schema args"]
        T7["7. expressMiddleware<br/>hostHeaderValidation<br/>signature update"]
    end

    subgraph "Phase 4: Context & Tests"
        T8["8. contextTypes<br/>extra → ctx,<br/>property path remapping"]
        T9["9. mockPaths<br/>vi.mock / jest.mock<br/>specifier updates"]
    end

    T1 --> T2 --> T3 --> T4 --> T5 & T6 & T7 --> T8 --> T9
Loading

Import Resolution Strategy

Some v1 paths (e.g., @modelcontextprotocol/sdk/types.js) are shared code that could belong to either the client or server package. The codemod resolves these contextually:

flowchart TD
    A["v1 import path"] --> B{Path in<br/>IMPORT_MAP?}
    B -->|no| Z[Leave unchanged]
    B -->|yes| C{Status?}
    C -->|removed| D["Remove import +<br/>emit warning diagnostic"]
    C -->|moved| E{Target is<br/>RESOLVE_BY_CONTEXT?}
    C -->|renamed| F["Rewrite path +<br/>rename symbols"]
    E -->|no| G["Rewrite to<br/>fixed target package"]
    E -->|yes| H{Project type?}
    H -->|client only| I["→ @modelcontextprotocol/client"]
    H -->|server only| J["→ @modelcontextprotocol/server"]
    H -->|both or unknown| K["→ @modelcontextprotocol/server<br/>(safe default)"]
Loading

Context Property Remapping

The v2 SDK restructures the handler context from a flat object into nested groups. The contextTypes transform handles this remapping:

flowchart LR
    subgraph "v1 — flat RequestHandlerExtra"
        E1["extra.signal"]
        E2["extra.requestId"]
        E3["extra.authInfo"]
        E4["extra.sendRequest(...)"]
        E5["extra.sendNotification(...)"]
        E6["extra.taskStore"]
    end

    subgraph "v2 — nested BaseContext"
        C1["ctx.mcpReq.signal"]
        C2["ctx.mcpReq.id"]
        C3["ctx.http?.authInfo"]
        C4["ctx.mcpReq.send(...)"]
        C5["ctx.mcpReq.notify(...)"]
        C6["ctx.task?.store"]
    end

    E1 --> C1
    E2 --> C2
    E3 --> C3
    E4 --> C4
    E5 --> C5
    E6 --> C6
Loading

What Each Transform Does

# Transform ID Description
1 imports Rewrites all @modelcontextprotocol/sdk/... import paths to their v2 package destinations. Merges duplicate imports, separates type-only imports, resolves ambiguous shared paths by project type.
2 symbols Renames 9 symbols (e.g., McpErrorProtocolError). Splits ErrorCode into ProtocolErrorCode + SdkErrorCode based on member usage. Converts RequestHandlerExtraServerContext/ClientContext. Replaces SchemaInput<T> with StandardSchemaWithJSON.InferInput<T>.
3 removed-apis Removes references to dropped Zod helpers (schemaToJson, parseSchemaAsync, etc.), renames IsomorphicHeadersHeaders, converts StreamableHTTPErrorSdkError with constructor mapping warnings.
4 mcpserver-api Rewrites McpServer method calls: .tool().registerTool(), .prompt().registerPrompt(), .resource().registerResource(). Restructures 2-4 positional args into (name, config, callback) form. Wraps raw object schemas with z.object().
5 handlers Converts server.setRequestHandler(CallToolRequestSchema, ...) to server.setRequestHandler('tools/call', ...) using the schema-to-method mapping table. Covers 15 request schemas and 8 notification schemas.
6 schema-params Removes the schema parameter from .request(), .callTool(), and .send() calls where the second argument is a schema reference (ends with Schema).
7 express-middleware Updates hostHeaderValidation({ allowedHosts: [...] })hostHeaderValidation([...]).
8 context Renames the extra callback parameter to ctx. Rewrites 13 property access paths from the flat v1 context to the nested v2 context structure. Warns on destructuring patterns that need manual review.
9 mock-paths Rewrites vi.mock() / jest.mock() specifiers from v1 to v2 paths. Updates import() expressions in mock factories. Renames symbol references inside mock factory return objects.

CLI Usage

# Run all transforms
mcp-codemod v1-to-v2 ./src

# Dry run — preview without writing
mcp-codemod v1-to-v2 ./src --dry-run --verbose

# Run specific transforms only
mcp-codemod v1-to-v2 ./src --transforms imports,symbols,context

# List available transforms
mcp-codemod v1-to-v2 --list

# Ignore additional patterns
mcp-codemod v1-to-v2 ./src --ignore "legacy/**" "generated/**"

Programmatic API

import { getMigration, run } from '@modelcontextprotocol/codemod';

const migration = getMigration('v1-to-v2')!;
const result = run(migration, {
  targetDir: './src',
  dryRun: false,
  verbose: true,
  transforms: ['imports', 'symbols'],
  ignore: ['test/**']
});

console.log(`${result.filesChanged} files changed, ${result.totalChanges} total changes`);
for (const d of result.diagnostics) {
  console.log(`[${d.level}] ${d.file}:${d.line}${d.message}`);
}

How Has This Been Tested?

  • 138 test cases across 12 test suites covering every transform, the CLI, the runner, and the project analyzer.
  • Each transform has its own dedicated test suite with isolated ts-morph in-memory filesystem tests.
  • An integration test runs the full pipeline against a realistic v1 source file and verifies the complete output.
  • Edge cases tested: duplicate imports, type-only imports, removed APIs, ambiguous shared paths, ErrorCode member splitting, destructuring patterns, mock factories, .d.ts exclusion, unknown transform ID validation.

Breaking Changes

None — this is a new package with no existing users.

Types of changes

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Design Decisions

  1. ts-morph over jscodeshift: ts-morph provides full TypeScript type information and a more ergonomic API for the precise symbol-level transforms this codemod requires (e.g., distinguishing ErrorCode.RequestTimeout from ErrorCode.InvalidRequest for the ProtocolErrorCode/SdkErrorCode split).

  2. Ordered transforms with per-file isolation: Transforms run in a declared order (imports first, mocks last). If a transform throws for a given file, the remaining transforms are skipped for that file (since the AST may be in a partially-mutated state), but processing continues for other files. An error diagnostic is emitted for the affected file.

  3. Declarative mapping tables: All rename/remap rules live in dedicated mapping files (importMap.ts, symbolMap.ts, etc.) rather than being scattered across transform logic. This makes the migration rules auditable and easy to extend.

  4. Context-aware import resolution: Shared v1 paths like @modelcontextprotocol/sdk/types.js are resolved to client or server packages based on package.json dependency analysis, not just hardcoded defaults.

  5. Diagnostic-first approach for removals: When the codemod encounters removed APIs (WebSocket transport, server auth, Zod helpers), it doesn't silently drop them — it emits clear warning diagnostics with migration guidance so users know what needs manual attention.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: 9ce1fcd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1950

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@1950

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1950

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1950

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@1950

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1950

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1950

commit: 9ce1fcd

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts Outdated
Comment thread packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/cli.ts Outdated
Comment thread packages/codemod/src/cli.ts
Comment thread packages/codemod/src/utils/projectAnalyzer.ts
@KKonstantinov KKonstantinov changed the title feat: add v2 codemode draft feat: add v2 codemod draft Apr 23, 2026
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts Outdated
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

Todo:

  • Test on sample size MCP SDK (v1) dependents
  • Test on everything-server
  • Test on v1 MCP SDK examples

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/runner.ts Outdated
Comment thread packages/codemod/src/runner.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/utils/astUtils.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts
Comment thread packages/codemod/src/runner.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +32 to +34
"files": [
"dist"
],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new @modelcontextprotocol/codemod package is added to the publish workflow but ships with no README.md, while every other published package in the repo (client, server, middleware/*) has one. The PR description's CLI usage, programmatic API, and transform table won't be visible on the npm package page — worth adding a packages/codemod/README.md before this lands (npm auto-includes README regardless of the files array, so no package.json change is needed).

Extended reasoning...

What the gap is

This PR adds @modelcontextprotocol/codemod as a new published package — it has a bin entry (mcp-codemod), a programmatic API export, and is added to .github/workflows/publish.yml:43 so pkg-pr-new is already publishing preview builds. But packages/codemod/ contains no README.md. Every other published package in the repo has one: packages/client/README.md, packages/server/README.md, and all four packages/middleware/*/README.md files exist.

The repo's own review checklist (REVIEW.md) explicitly calls this out: "New feature: verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example." A new package with both a CLI and a programmatic API is squarely in scope for that item.

Why it matters

The PR description already contains exactly the documentation users need — the CLI Usage block (mcp-codemod v1-to-v2 ./src, --dry-run, --transforms, --list, --ignore), the Programmatic API snippet (getMigration / run), and the 9-row transform table. None of that will be visible on npm. A user who runs npx @modelcontextprotocol/codemod and gets the commander help text will have nowhere to look for the transform IDs, the design decisions, or the migration scope.

Step-by-step proof

  1. .github/workflows/publish.yml:43 adds './packages/codemod' to the pkg-pr-new publish command, so the package is published on every PR push (and presumably on release).
  2. packages/codemod/package.json:32-34 sets "files": ["dist"] and a bin entry — so the package is intended for end-user consumption via npx.
  3. ls packages/codemod/ shows: eslint.config.mjs, package.json, src/, test/, tsconfig.json, tsdown.config.ts, typedoc.json, vitest.config.js — no README.md.
  4. ls packages/*/README.md packages/middleware/*/README.md shows READMEs for client, server, express, fastify, hono, and node — codemod is the only published package without one.
  5. On npm, the package page renders the README as the primary documentation surface. With no README, the page shows only the package.json description string: "Codemod to migrate MCP TypeScript SDK code from v1 to v2" — no usage, no flags, no transform list.

Note on files array

npm always includes README* (along with package.json, LICENSE*, and the main entry) regardless of the files allowlist, so adding packages/codemod/README.md is sufficient — no need to touch "files": ["dist"].

Fix

Add packages/codemod/README.md containing (at minimum) the CLI Usage block, the Programmatic API snippet, and the transform table from this PR's description. Given the PR is explicitly titled "draft" and the author has an open TODO checklist, this is likely already planned — flagging it here per the REVIEW.md checklist item so it doesn't slip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBD. Add README.md once the feasibility of the codemod has been confirmed.

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts
Comment thread packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts Outdated
Comment on lines +76 to +81
export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean {
return sourceFile.getImportDeclarations().some(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => n.getName() === symbolName);
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 isImportedFromMcp() compares against n.getName() (the export name), but callers at handlerRegistration.ts:40 and schemaParamRemoval.ts:31 pass firstArg.getText()/secondArg.getText() — the local binding name — so an aliased MCP import plus a same-named local import confuses the provenance check. The new test 'does not remove non-MCP import when MCP import of same name is consumed' (handlerRegistration.test.ts:108-119) actually locks in the wrong behavior: CallToolRequestSchema there is bound to ./local-schemas.js, yet the test asserts it's rewritten to 'tools/call'. Note that mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding (n.getAliasNode()?.getText() ?? n.getName()) only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally.

Extended reasoning...

What the bug is

isImportedFromMcp(sourceFile, symbolName) (importUtils.ts:76-81) tests imp.getNamedImports().some(n => n.getName() === symbolName). ts-morph's ImportSpecifier.getName() returns the export name (the identifier before as), not the local alias — this is the same fact already established and acted on elsewhere in this PR (e.g., resolved comment 3132456083, and removedApis.ts using foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders' to derive the local name).

Two of the four callers pass the local identifier text as it appears in the body:

  • handlerRegistration.ts:40schemaName = firstArg.getText()
  • schemaParamRemoval.ts:31schemaName = secondArg.getText()

For these callers, comparing the body identifier against the export name is a category error whenever an alias is involved.

The concrete manifestation: a test that asserts wrong behavior

The most actionable evidence is the new test at handlerRegistration.test.ts:108-119:

import { CallToolRequestSchema } from './local-schemas.js';
import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';
server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));
validateSchema(McpSchema);

In this fixture, the CallToolRequestSchema passed to setRequestHandler is the local binding from ./local-schemas.js — the MCP one is aliased to McpSchema. The test asserts expect(result).toContain("'tools/call'") and not.toMatch(/setRequestHandler\(CallToolRequestSchema/), i.e., it expects the local schema usage to be rewritten as if it were the MCP schema. That's the false positive, encoded as expected behavior. (The test was presumably written to verify that removeUnusedImport(..., onlyMcpImports=true) doesn't strip the local import — which it does verify — but the rewrite assertion is incorrect.)

Step-by-step proof (false positive)

  1. firstArg.getText() = 'CallToolRequestSchema'.
  2. ALL_SCHEMA_TO_METHOD['CallToolRequestSchema'] = 'tools/call' → passes line 38.
  3. isImportedFromMcp(sf, 'CallToolRequestSchema'): iterates imports; the second declaration is an MCP specifier; its named import has getName() === 'CallToolRequestSchema' (the export name on the aliased specifier) → returns true.
  4. firstArg.replaceWithText("'tools/call'") — but firstArg referred to the local ./local-schemas.js binding, not the MCP one.
  5. removeUnusedImport(sf, 'CallToolRequestSchema', true) then scans for body references to CallToolRequestSchema, finds none (it was just replaced), and — correctly — only removes the MCP-aliased specifier? Actually no: removeUnusedImport at importUtils.ts:96 also matches on namedImport.getName() (export name), so it would try to remove the aliased MCP specifier, but McpSchema is still referenced in validateSchema(McpSchema), so referenceCount > 0 and nothing is removed. Hence the local import survives (which is what the test's first assertion checks), but the body call was still wrongly rewritten.

Addressing the refutation

A reviewer objected that (a) the proposed fix breaks the other two callers, (b) the false-negative paths are gated elsewhere, and (c) the trigger is contrived. All three points are fair and worth incorporating:

  • (a) is correct and important. mcpServerApi.ts:15 and expressMiddleware.ts:12 pass literal export names ('McpServer', 'hostHeaderValidation') as a file-level gate. For import { McpServer as Server }, n.getName() === 'McpServer' is exactly what they want. Changing the function to only check the alias would regress them. So the fix is not the one-liner originally proposed; it's either a second helper (e.g., isLocalIdentifierFromMcp) for the body-identifier callers, or having the function match either form and letting callers 1/2 pass the local text while callers 3/4 keep passing the export name — but "match either" doesn't fix the false positive, so a separate helper is cleaner.
  • (b) is largely correct. The false-negative in handlerRegistration is moot because ALL_SCHEMA_TO_METHOD[aliasName] is undefined and short-circuits first. In schemaParamRemoval, isSchemaReference requires the text to end in 'Schema', so an alias like RS is filtered out before the provenance check; only an alias that itself ends in Schema (e.g., as ResultSchema) reaches the bug and is then silently skipped — safe (no corruption), just an incomplete migration.
  • (c) is correct on frequency, which is why this is a nit. But the fact that the test suite itself encodes the false positive as expected output is worth a one-line correction regardless of how rare the pattern is in the wild — tests that assert wrong behavior tend to bite later.

Impact

Nit severity. The output-corrupting path requires importing the same export name from both an MCP package (aliased) and a non-MCP source in one file — uncommon. The false-negative paths are mostly gated by earlier checks and, where reachable, leave code unchanged. The main value of the fix is (1) correcting the test so it doesn't lock in a wrong rewrite, and (2) eliminating an internal inconsistency where one helper serves two semantically different caller intents.

Fix

Introduce a local-binding variant for the body-identifier callers and leave the existing function for the file-level gates:

export function isLocalIdentifierFromMcp(sourceFile: SourceFile, localName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp => {
        if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
        return imp.getNamedImports().some(n => (n.getAliasNode()?.getText() ?? n.getName()) === localName);
    });
}

Use it at handlerRegistration.ts:40 and schemaParamRemoval.ts:31. Then update the test at handlerRegistration.test.ts:108-119 to assert the correct behavior: the setRequestHandler(CallToolRequestSchema, ...) call (local binding) is not rewritten, and 'tools/call' does not appear.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the false-positive is fixed (handlerRegistration/schemaParamRemoval now resolve via local binding + resolveOriginalImportName), and the test at handlerRegistration.test.ts now asserts correct behavior. 👍

One leftover: isImportedFromMcp was changed unconditionally to match on the local binding, which regresses the file-level-gate caller the comment flagged. mcpServerApi.ts:15 still passes the hardcoded export name 'McpServer', so for import { McpServer as Server } from '@modelcontextprotocol/server' the gate now returns false (the local name is 'Server') and every .tool()/.prompt()/.resource() call in that file is silently skipped — previously n.getName() === 'McpServer' matched the export name on the aliased specifier and the transform ran. (expressMiddleware.ts:12 is moot in practice since its body check at line 32 also matches on the literal identifier text.)

"Match either form" won't work here — it would re-introduce the false positive (the comment's refutation point (a) covers this). Simplest fix is a second helper for the export-name semantics and switch mcpServerApi.ts:15 to it:

export function isExportImportedFromMcp(sourceFile: SourceFile, exportName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp =>
        isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
        imp.getNamedImports().some(n => n.getName() === exportName)
    );
}

(Low-frequency edge case, so still nit — but it's a behavioral regression from the previous commit.)

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +10 to +14
if (Node.isExportSpecifier(parent)) {
if (!parent.getAliasNode()) parent.setAlias(oldName);
parent.getNameNode().replaceWithText(newName);
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The ExportSpecifier branch added for comment 3136237226 checks only Node.isExportSpecifier(parent) without verifying whether the matched identifier is the name node or the alias node — unlike every sibling guard (lines 15-24) which checks parent.getNameNode() === node. For export { Foo as McpError }, forEachDescendant visits the alias identifier McpError, parent.getAliasNode() is truthy so setAlias is skipped, then parent.getNameNode().replaceWithText('ProtocolError') overwrites Fooexport { ProtocolError as McpError }, silently dropping the user's local binding. Add if (parent.getAliasNode() === node) return; at the top of the branch.

Extended reasoning...

What the bug is

The ExportSpecifier branch at astUtils.ts:10-14 was added in response to comment 3136237226 to rewrite export { McpError }export { ProtocolError as McpError }. But it only checks Node.isExportSpecifier(parent) and then unconditionally operates on parent.getNameNode() — it never verifies whether the matched identifier node is the name node (the local binding, before as) or the alias node (the public export name, after as). Every other guard in this function (lines 15-24: PropertyAssignment, PropertyAccessExpression, PropertySignature, MethodDeclaration, BindingElement, etc.) explicitly checks parent.getNameNode() === node before acting; this branch is the inconsistent one.

Code path that triggers it

In ts-morph, for export { Foo as McpError }, ExportSpecifier.getNameNode() returns the Foo identifier and getAliasNode() returns the McpError identifier. forEachDescendant visits both identifiers. When it reaches the alias McpError:

  1. Node.isIdentifier(node) && node.getText() === 'McpError' → true (matches oldName).
  2. Node.isExportSpecifier(parent) → true.
  3. parent.getAliasNode() is truthy (it's the McpError node) → setAlias is skipped.
  4. parent.getNameNode().replaceWithText('ProtocolError') → replaces Foo with ProtocolError.

Result: export { ProtocolError as McpError }. The user's local Foo binding is silently dropped, and ProtocolError may not even exist as a local binding (if the file imported the unaliased MCP McpError, the import was renamed to ProtocolError, so it happens to resolve — but the semantics changed: the module now exports the MCP error class instead of the user's Foo).

Why existing code doesn't prevent it

The branch correctly handles two of three cases: (a) export { McpError } — no alias, setAlias(oldName) then rename → export { ProtocolError as McpError } ✓; (b) export { McpError as Something }forEachDescendant matches the name node McpError, alias is truthy so setAlias skipped, name replaced → export { ProtocolError as Something } ✓. But case (c) export { Foo as McpError } — where only the alias matches oldName — falls through the same code path and corrupts the name slot. The new test 'preserves export specifier public name with alias' (symbolRenames.test.ts) only covers case (a). No test covers an export specifier whose alias matches a SIMPLE_RENAMES key.

Step-by-step proof

Input (file imports MCP McpError to trigger renameAllReferences, and separately re-exports a local class under the same public name):

import { McpError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { AppError as McpError };
if (e instanceof McpError) { ... }
  1. symbolRenamesTransform finds McpError in the MCP import, calls namedImport.setName('ProtocolError'), then renameAllReferences(sf, 'McpError', 'ProtocolError').
  2. forEachDescendant visits the import specifier → Node.isImportSpecifier(parent) returns early. ✓
  3. Visits McpError in the instanceof check → falls through to node.replaceWithText('ProtocolError'). ✓
  4. Visits the alias McpError in export { AppError as McpError }parent is ExportSpecifier, getAliasNode() truthy → getNameNode().replaceWithText('ProtocolError') rewrites AppError.

Output:

import { ProtocolError } from '@modelcontextprotocol/sdk/types.js';
class AppError extends Error {}
export { ProtocolError as McpError };   // ← was AppError; now exports the MCP class instead
if (e instanceof ProtocolError) { ... }

The module's public McpError export silently changed from the user's AppError to the SDK's ProtocolError — still compiles, no diagnostic, semantically wrong. (One verifier reproduced this empirically against ts-morph.)

Impact

Nit severity: the trigger requires a file that both (a) imports a SIMPLE_RENAMES key unaliased from MCP (to fire renameAllReferences) and (b) re-exports a different local under that same public name — uncommon but valid TypeScript. When it fires, though, it's the worst codemod failure mode: silent semantic corruption that type-checks. And it's a one-line internal-consistency fix in a branch that was just added in response to review.

Fix

Add a guard at the top of the branch matching the pattern used by every sibling:

if (Node.isExportSpecifier(parent)) {
    if (parent.getAliasNode() === node) return;   // alias is the public export name — leave it
    if (!parent.getAliasNode()) parent.setAlias(oldName);
    parent.getNameNode().replaceWithText(newName);
    return;
}

(Equivalently, if (parent.getNameNode() !== node) return;.) A test like import { McpError } from '@modelcontextprotocol/server'; class Foo {} export { Foo as McpError }; → expect output to contain export { Foo as McpError } would lock this in.

Comment on lines +76 to +84
export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean {
return sourceFile.getImportDeclarations().some(imp => {
if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false;
return imp.getNamedImports().some(n => {
const localName = n.getAliasNode()?.getText() ?? n.getName();
return localName === symbolName;
});
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The latest commit changed isImportedFromMcp() to compare only against the local binding (n.getAliasNode()?.getText() ?? n.getName()), but mcpServerApi.ts:15 and expressMiddleware.ts:12 still pass hardcoded export names — exactly what unresolved comment 3137901202 warned against. So import { McpServer as Server } from '...' now makes isImportedFromMcp(sf, 'McpServer') return false and the entire mcpserver-api transform silently no-ops, regressing previously-working aliased-import handling. Add a separate export-name helper for the two file-level gates (note: simply matching either name would re-break the handlerRegistration false-positive the latest commit just fixed).

Extended reasoning...

What the bug is

isImportedFromMcp (importUtils.ts:76-84) was changed in the latest commit from n.getName() === symbolName (matching the export name) to:

const localName = n.getAliasNode()?.getText() ?? n.getName();
return localName === symbolName;

i.e., it now matches only the local binding name. This was done to fix the false-positive flagged in comment 3137901202 for handlerRegistration.ts:40 and schemaParamRemoval.ts:31, which pass body-identifier text (a local name). But that same comment explicitly warned: "mcpServerApi.ts:15 and expressMiddleware.ts:12 pass hardcoded export names and rely on the current semantics, so the fix is to check the local binding only for the body-identifier callers — e.g., a separate helper or an optional flag — rather than changing the function unconditionally." The function was changed unconditionally anyway, and those two callers were not updated.

Code path that triggers it

mcpServerApi.ts:15 calls isImportedFromMcp(sourceFile, 'McpServer') as a file-level gate; expressMiddleware.ts:12 calls isImportedFromMcp(sourceFile, 'hostHeaderValidation'). Both pass the export name as a literal string. With an aliased import, ts-morph's getAliasNode()?.getText() returns the alias, so localName is the alias — which never equals the hardcoded export name.

Why this is a regression

Before the latest commit, for import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js', n.getName() === 'McpServer' was true, the gate passed, and the collection loop (which has no receiver check) correctly migrated server.tool(...)server.registerTool(...). After the change, localName = 'Server', 'Server' !== 'McpServer', the gate returns false, and the transform returns { changesCount: 0 } without touching any .tool()/.prompt()/.resource() calls. No diagnostic is emitted. No test in mcpServerApi.test.ts covers an aliased McpServer import, so the regression is uncaught. (For expressMiddleware, the gate has the same issue, though the body check at line 32 was already literal-name-based — so the net effect there is unchanged; the observable regression is in mcpServerApi.)

Step-by-step proof

Input:

import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
const s = new Server({ name: 'test', version: '1.0' });
s.tool('greet', async () => ({ content: [] }));
  1. mcpServerApiTransform.apply() runs; line 15 calls isImportedFromMcp(sf, 'McpServer').
  2. importUtils.ts:78 — the import's specifier passes isAnyMcpSpecifier.
  3. importUtils.ts:80 — the single named import has getAliasNode()?.getText() = 'Server', so localName = 'Server'.
  4. importUtils.ts:81 — 'Server' === 'McpServer'false. .some() returns false.
  5. mcpServerApi.ts:15-17 — gate fails → return { changesCount: 0, diagnostics: [] }.
  6. s.tool('greet', ...) is never migrated. After importPaths rewrites the import to @modelcontextprotocol/server, the user is left with a v2 import and an un-migrated v1 .tool() call that produces a TypeScript error — with zero codemod diagnostics pointing at it.

Impact

Aliasing McpServer (e.g., as Server to avoid the Mcp prefix, or to disambiguate from another Server class) is uncommon but realistic. The failure mode is safe — the source is left unchanged rather than corrupted, and TypeScript will flag the unmigrated .tool() calls — hence nit severity. But it's a regression from the immediately-preceding commit, it was explicitly predicted in the still-unresolved review comment, and the fix is small.

Fix

Do not change isImportedFromMcp to match either export-or-local name — that would re-introduce the handlerRegistration false positive (and break the new test 'does not rewrite local import when aliased MCP import has same export name'). Instead, give the file-level gates an export-name check:

// importUtils.ts
export function isMcpExportImported(sourceFile: SourceFile, exportName: string): boolean {
    return sourceFile.getImportDeclarations().some(imp =>
        isAnyMcpSpecifier(imp.getModuleSpecifierValue()) &&
        imp.getNamedImports().some(n => n.getName() === exportName)
    );
}

Use it at mcpServerApi.ts:15 and expressMiddleware.ts:12. (Or add a { matchExportName?: boolean } flag to the existing helper.) A test with import { McpServer as Server } + s.tool(...) → expect registerTool would lock this in.

@KKonstantinov
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +106 to +135
const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined);
if (defaultImport || namespaceImport || hasAlias) {
let effectiveTarget = targetPackage;
if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) {
const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!);
if (allOverridden) {
effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!;
}
}
imp.setModuleSpecifier(effectiveTarget);
if (mapping.renamedSymbols) {
for (const n of namedImports) {
const newName = mapping.renamedSymbols[n.getName()];
if (newName) {
n.setName(newName);
}
}
if (namespaceImport) {
diagnostics.push(
warning(
filePath,
line,
`Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` +
`were renamed in ${effectiveTarget}. Update qualified accesses manually.`
)
);
}
}
changesCount++;
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When any specifier in a named import is aliased, the in-place setModuleSpecifier branch (line 107) fires, and the allOverridden check at line 110 only redirects to the override package when every specifier is in symbolTargetOverrides — so a mixed aliased import like import { StreamableHTTPServerTransport as T, EventStore } from '.../server/streamableHttp.js' is rewritten to import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server', which doesn't export NodeStreamableHTTPServerTransport (TS2305), with no diagnostic. The non-aliased path at lines 138-144 already routes per-symbol via mapping.symbolTargetOverrides?.[name] ?? targetPackage; consider falling through to it (it can carry aliases via addPending) or at minimum push a warning(...) when hasAlias && symbolTargetOverrides && !allOverridden. (rewriteExportDeclarations at line 222 has the same all-or-nothing gap for mixed re-exports.)

Extended reasoning...

What the bug is

The newly-added symbolTargetOverrides mechanism lets a single v1 module map different symbols to different v2 packages — currently used only for @modelcontextprotocol/sdk/server/streamableHttp.js, where StreamableHTTPServerTransport goes to @modelcontextprotocol/node while everything else (EventStore, StreamId, etc.) goes to @modelcontextprotocol/server. The non-aliased path at importPaths.ts:138-144 handles this correctly by routing each specifier individually:

const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage;
addPending(symbolTarget, [resolvedName], specifierTypeOnly);

But line 106 computes hasAlias = namedImports.some(n => n.getAliasNode() !== undefined), and if any specifier is aliased the whole declaration is sent into the in-place setModuleSpecifier branch (107-136). That branch can only set one module specifier, and the allOverridden guard at line 110 only redirects to the override package when every named import is a key in symbolTargetOverrides. For a mixed import, allOverridden is false, effectiveTarget stays at the base targetPackage (@modelcontextprotocol/server), and the renamed transport ends up imported from the wrong package.

Code path that triggers it

Input:

import { StreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

(Note that aliasing just one of the two specifiers is enough — .some() at line 106 fires on a single match.)

  1. mapping = { target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  2. Lines 100-104: renameAllReferences(sourceFile, 'StreamableHTTPServerTransport', 'NodeStreamableHTTPServerTransport') runs (no-op here since the body uses the alias T).
  3. Line 106: hasAlias = true → enters the in-place branch.
  4. Line 110: namedImports.every(n => n.getName() in {StreamableHTTPServerTransport: ...})EventStore is not a key → allOverridden = false.
  5. effectiveTarget stays '@modelcontextprotocol/server'; line 115 imp.setModuleSpecifier('@modelcontextprotocol/server').
  6. Lines 117-121: the specifier StreamableHTTPServerTransport is renamed to NodeStreamableHTTPServerTransport (alias T preserved).

Output:

import { NodeStreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/server';

TS2305: Module '"@modelcontextprotocol/server"' has no exported member 'NodeStreamableHTTPServerTransport'. No diagnostic is emitted (the only warning(...) in this branch is gated on namespaceImport).

Why existing code/tests don't catch it

The two existing tests bracket this case without covering it: 'handles aliased renamedSymbols correctly' (importPaths.test.ts:165) tests a single aliased specifier (so allOverridden is true → routes to /node ✓), and 'splits streamableHttp import: transport to /node, types to /server' (importPaths.test.ts:177) tests a mixed but unaliased import (so hasAlias is false → per-symbol addPending path ✓). The aliased-and-mixed combination falls between them.

The same all-or-nothing logic appears in rewriteExportDeclarations (line 222-227) — export { StreamableHTTPServerTransport, EventStore } from '.../streamableHttp.js' becomes export { NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/server' with the same TS2305 — and in rewriteMockCall / rewriteDynamicImports (mockPaths.ts), though those cases are even rarer.

Impact

Nit severity: only one IMPORT_MAP entry has symbolTargetOverrides, importing the transport and EventStore/StreamId from streamableHttp.js in a single declaration with at least one alias is uncommon, and the resulting TS2305 is immediate and points directly at the offending specifier. The codemod is also documented as 80-90% coverage. But it's an internal inconsistency in newly-added logic — the per-symbol routing was clearly intended (it's implemented for the common case three lines below), and this branch silently produces broken output with no diagnostic.

Step-by-step proof

Input: import { StreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

  • importPaths.ts:106 — namedImports = [⟨StreamableHTTPServerTransport as Transport⟩, ⟨EventStore as Store⟩]; .some(n => n.getAliasNode())true.
  • :107 — defaultImport=undefined, namespaceImport=undefined, hasAlias=true → enter branch.
  • :109 — mapping.symbolTargetOverrides truthy, !namespaceImport && !defaultImport → enter.
  • :110 — namedImports.every(...): 'StreamableHTTPServerTransport' in overrides ✓, 'EventStore' in overrides ✗ → false.
  • :111-113 — skipped; effectiveTarget = '@modelcontextprotocol/server'.
  • :115 — imp.setModuleSpecifier('@modelcontextprotocol/server').
  • :117-121 — n.getName()='StreamableHTTPServerTransport'n.setName('NodeStreamableHTTPServerTransport'); n.getName()='EventStore' → no rename.
  • :123 — namespaceImport falsy → no warning.
  • :134-135 — changesCount++; continue.

Final: import { NodeStreamableHTTPServerTransport as Transport, EventStore as Store } from '@modelcontextprotocol/server';NodeStreamableHTTPServerTransport is exported from @modelcontextprotocol/node, not /server. diagnostics = [].

Fix

Either drop the hasAlias short-circuit and let aliased named-only imports fall through to the per-symbol addPending loop (extending PendingImport to carry an alias?: string so addOrMergeImport can pass { name, alias } specifiers — ts-morph's insertImportDeclaration already accepts that shape), or keep the branch but split the declaration in place when symbolTargetOverrides && !allOverridden: move the overridden specifiers to a new import for the override target and leave the rest on targetPackage. At minimum, push a warning(filePath, line, ...) for this case so it's not silent.

Comment on lines +121 to +129
function collectFactorySymbols(factoryArg: import('ts-morph').Node): string[] {
const symbols: string[] = [];
factoryArg.forEachDescendant(node => {
if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) {
symbols.push(node.getName());
}
});
return symbols;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 collectFactorySymbols uses forEachDescendant to collect property names at any depth, so for the canonical class-mocking pattern vi.mock('.../server/streamableHttp.js', () => ({ StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) })) the nested handleRequest key is collected, allOverridden (line 105) is false, and the mock specifier is rewritten to @modelcontextprotocol/server instead of @modelcontextprotocol/node — so the mock no longer intercepts the module the migrated code actually imports the transport from. Only inspect the top-level properties of the factory's returned object literal (find the return value and iterate .getProperties()); the same depth issue in renameSymbolsInFactory (lines 131-153) would also rename a nested { McpError: ... } instance-property key inside a .mockImplementation body.

Extended reasoning...

What the bug is

collectFactorySymbols (mockPaths.ts:121-129) does:

factoryArg.forEachDescendant(node => {
    if (Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node)) {
        symbols.push(node.getName());
    }
});

forEachDescendant visits every PropertyAssignment/ShorthandPropertyAssignment at any nesting depth inside the factory argument — not just the top-level keys of the object the factory returns. The result feeds the symbolTargetOverrides heuristic at line 105: factorySymbols.every(s => s in resolved.symbolTargetOverrides!). That check is meant to ask "is every export this factory mocks one that moved to the override package?" — but because nested object-literal keys (instance methods, config objects, etc.) leak into the list, the .every() is false whenever the factory contains any nested object literal.

Code path that triggers it

The canonical vitest/jest pattern for mocking a class is:

vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
    StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({
        handleRequest: vi.fn(),
        sessionId: 'test-session'
    }))
}));

Walking through rewriteMockCall:

  1. resolveTarget(...){ target: '@modelcontextprotocol/server', renamedSymbols: { StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' }, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  2. Line 103-104: collectFactorySymbols(args[1]) walks the arrow function. forEachDescendant visits the outer StreamableHTTPServerTransport: ... assignment and the inner handleRequest: vi.fn() and sessionId: 'test-session' assignments → returns ['StreamableHTTPServerTransport', 'handleRequest', 'sessionId'].
  3. Line 105: ['StreamableHTTPServerTransport', 'handleRequest', 'sessionId'].every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' })'handleRequest' in {...} is falseallOverridden = false.
  4. effectiveTarget stays at resolved.target = '@modelcontextprotocol/server'.
  5. Line 110: firstArg.setLiteralValue('@modelcontextprotocol/server').
  6. renameSymbolsInFactory then renames the top-level key to NodeStreamableHTTPServerTransport.

Output:

vi.mock('@modelcontextprotocol/server', () => ({
    NodeStreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn(), sessionId: 'test-session' }))
}));

But the migrated production code now does import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node' (per importPathsTransform's symbolTargetOverrides handling). The mock intercepts @modelcontextprotocol/server, not @modelcontextprotocol/node, so vitest/jest loads the real transport — the test silently exercises real I/O (or throws on construction) with no codemod diagnostic explaining why.

Why existing code doesn't prevent it

The existing test 'renames symbols in vi.doMock factory for streamableHttp' (mockPaths.test.ts:31-41) uses a flat factory () => ({ StreamableHTTPServerTransport: mockTransport }) with no nested properties, so collectFactorySymbols returns exactly ['StreamableHTTPServerTransport'], allOverridden is true, and /node is correctly chosen. Nothing exercises a nested .mockImplementation(() => ({...})) body, even though that's the dominant real-world shape for mocking a class constructor.

The same forEachDescendant depth issue exists in renameSymbolsInFactory (lines 131-153): a nested { McpError: someValue } inside a .mockImplementation body — i.e., an instance property on the mock, not a module export — would be renamed to ProtocolError, silently changing the mock's instance shape. That collision is less likely than the handleRequest case but is the same root cause.

Impact

When triggered, the user's migrated test file mocks the wrong module: the mock factory provides NodeStreamableHTTPServerTransport on @modelcontextprotocol/server (which doesn't export it), while the code under test imports it from @modelcontextprotocol/node and gets the real class. The failure surfaces as a test failure the user has to debug manually, with no codemod diagnostic — exactly the silent-test-behavior-change a migration tool should avoid. Nit severity because: only one symbolTargetOverrides entry exists today, it only affects test files, and the user's own test suite will catch the broken mock (loudly, if not informatively). But vi.fn().mockImplementation(() => ({...methods})) is the most common shape for class mocks, so the heuristic is broken for the majority of real inputs it was added to handle.

Step-by-step proof

Input:

vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
    StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() }))
}));
  • mockPaths.ts:80 — specifier = '@modelcontextprotocol/sdk/server/streamableHttp.js'; isSdkSpecifier → true.
  • mockPaths.ts:82 — resolveTarget returns { target: '@modelcontextprotocol/server', renamedSymbols: {...}, symbolTargetOverrides: { StreamableHTTPServerTransport: '@modelcontextprotocol/node' } }.
  • mockPaths.ts:102-103 — resolved.symbolTargetOverrides truthy, args.length === 2 → call collectFactorySymbols(args[1]).
  • mockPaths.ts:123 — forEachDescendant on the arrow function visits, in order: the outer ObjectLiteralExpression's PropertyAssignment (StreamableHTTPServerTransport: ...) → push 'StreamableHTTPServerTransport'; then descends into vi.fn().mockImplementation(() => ({...})), reaches the inner ObjectLiteralExpression's PropertyAssignment (handleRequest: vi.fn()) → push 'handleRequest'. Returns ['StreamableHTTPServerTransport', 'handleRequest'].
  • mockPaths.ts:105 — .every(s => s in { StreamableHTTPServerTransport: '@modelcontextprotocol/node' }): 'StreamableHTTPServerTransport' ✓, 'handleRequest' ✗ → false.
  • mockPaths.ts:106 — skipped; effectiveTarget remains '@modelcontextprotocol/server'.
  • mockPaths.ts:110 — specifier rewritten to '@modelcontextprotocol/server'.
  • mockPaths.ts:115 — renameSymbolsInFactory renames the outer key to NodeStreamableHTTPServerTransport (and leaves handleRequest alone since it's not in allRenames).

Final: vi.mock('@modelcontextprotocol/server', () => ({ NodeStreamableHTTPServerTransport: ... })) — wrong target package.

Fix

Restrict collection to the top-level properties of the factory's returned object literal. Locate the returned expression (arrow body if a parenthesized object, or the ReturnStatement argument if a block body), guard with Node.isObjectLiteralExpression, and iterate .getProperties() filtered to PropertyAssignment | ShorthandPropertyAssignment:

function collectFactorySymbols(factoryArg: Node): string[] {
    let returned: Node | undefined;
    if (Node.isArrowFunction(factoryArg) || Node.isFunctionExpression(factoryArg)) {
        const body = factoryArg.getBody();
        returned = Node.isBlock(body)
            ? body.getStatements().find(Node.isReturnStatement)?.getExpression()
            : body;
        if (returned && Node.isParenthesizedExpression(returned)) returned = returned.getExpression();
    }
    if (!returned || !Node.isObjectLiteralExpression(returned)) return [];
    return returned
        .getProperties()
        .filter(p => Node.isPropertyAssignment(p) || Node.isShorthandPropertyAssignment(p))
        .map(p => (p as any).getName());
}

Apply the same top-level-only restriction to renameSymbolsInFactory (or have it operate on the same returned.getProperties() list) so nested instance-property keys aren't renamed. Add a test with vi.fn().mockImplementation(() => ({ handleRequest: vi.fn() })) asserting the specifier becomes '@modelcontextprotocol/node'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant